feat: support MCP SDK 2.x alongside 1.x - #33
Conversation
…on Windows test_proxy_process_exits_on_401_with_stdin_still_open builds the child's environment from scratch so a developer's real DATABRICKS_* settings cannot reach it. On Windows that also drops SystemRoot, and without it winsock cannot initialize: the child dies importing asyncio's proactor event loop with OSError WinError 10106 before it ever reaches the code under test, so the test fails for a reason unrelated to what it asserts. Carries SystemRoot and SystemDrive through while keeping the env otherwise built from scratch. No behaviour change on Linux, where CI runs.
88a8b00 to
51033d6
Compare
SDK 2.0 changed three things this proxy depends on, so the dependency was capped at <2 rather than ported: * the HTTP client library moved from httpx to httpx2 * streamable_http_client yields (read, write), dropping get_session_id * SessionMessage.message is the JSON-RPC model itself, no longer wrapped in the JSONRPCMessage root model Adds src/uc_mcp_proxy/_compat.py, which resolves the HTTP library by reading it back off mcp.client.streamable_http rather than importing a guessed name. httpx and httpx2 install side by side -- httpx2 does not replace httpx, and databricks-sdk still pulls httpx in -- so `try: import httpx2` would hand an SDK 1.x transport a client built from a library that SDK never imported. Because the proxy owns the client it passes to the transport, that mismatch surfaces as a type error deep in the SDK request path, not at import time. The dropped third yield element is absorbed by a starred unpack; it was bound to _get_session_id and never used. bridge() and copy_stream() now take structural stream protocols, since 1.x hands out anyio memory streams and 2.0 hands out its own context-carrying wrappers. The boundary matters in both directions. Objects the SDK will see come from _compat; the RFC 8693 token exchange is the proxy's own request and never crosses that boundary, so token_exchange keeps importing httpx directly and its tests build httpx transports to match. A MockTransport from the other library is silently not used, and the exchange then tries to reach the real network -- which is how this was caught. Tests build messages through tests/support.py, validating a raw wire payload through the SDK's own schema rather than a hand-built object graph, so the same test reads correctly on both majors. CI gains a test-sdk-majors job: the lockfile only ever proves one major, so each supported major is resolved and exercised explicitly. Verified on mcp 1.29.0 and 2.0.0: unit suite (411 passed), mypy, and a live end-to-end run of the real proxy subprocess against a real MCP Streamable HTTP server over a real localhost socket (initialize, tools/list, tools/call round-trip).
51033d6 to
5299374
Compare
| requires-python = ">=3.10" | ||
| dependencies = [ | ||
| "mcp>=1.8,<2", | ||
| "mcp>=1.8", |
There was a problem hiding this comment.
Blocking: this removes the only major cap. The shim is tested for 1.x/2.x, but a future 3.x would now resolve automatically. Please use mcp>=1.24,<3: 1.24 is the actual lower API requirement, and <3 limits installs to the majors this PR supports.
There was a problem hiding this comment.
Fixed in commit 4b1adf9. The MCP dependency is now bounded to >=1.24,<3, matching the actual API floor and the two supported SDK majors. Please re-review.
|
Blocking follow-up on |
Copilot-Session: e4671a15-63e3-40bc-a13d-4677903f8b2f
|
Fixed the httpx2 follow-up in commit 4b1adf9. httpx2 is now a direct dependency bounded to >=2.5,<3. The rationale and retry-invariant coverage now apply to both HTTP implementations, and CI upgrades and tests the corresponding latest supported HTTP client in each SDK-major lane instead of relying on the lockfile. Please re-review. |
IceRhymers
left a comment
There was a problem hiding this comment.
Thank you for the contribution!
What
Lifts the
mcp<2cap and makes the proxy work on both MCP SDK 1.x and 2.x.b861f51capped the dependency atmcp>=1.8,<2, which keeps installs working but leaves 2.x unsupported. On 2.0 the proxy fails at startup:Because the version range is only capped in the published metadata,
uvx uc-mcp-proxyresolvesmcpfreely for anyone who already has a 2.x-compatible environment, and the failure surfaces as an unhandledExceptionGrouptraceback rather than a diagnosis.Why it is more than the unpack
SDK 2.0 changed three things this proxy depends on:
httpxhttpx2streamable_http_clientyield(read, write, get_session_id)(read, write)SessionMessage.messageJSONRPCMessageroot modelThe third one is the one that bites silently:
inject_metareached throughmessage.message.root, which no longer exists, so--metainjection would break at runtime rather than at import.How
src/uc_mcp_proxy/_compat.pyresolves the HTTP library by reading it back offmcp.client.streamable_httpinstead of importing a guessed name:try: import httpx2would have been wrong. Both libraries install side by side — httpx2 does not replace httpx, anddatabricks-sdkstill pulls httpx in — so an SDK 1.x user with httpx2 present would get a client built from a library that their SDK never imported. Since the proxy owns the client it hands tostreamable_http_client(the design inCLAUDE.md§ Error handling), that mismatch surfaces as a type error deep in the SDK's request path instead of at import time. Asking the SDK which module it bound is the only answer that cannot drift._get_session_idand never used.bridge()/copy_stream()now take structural stream protocols, because 1.x hands out anyio memory streams and 2.0 hands out its own context-carrying wrappers. Naming either concrete class type-checks against one SDK and fails on the other.import httpxremains for anything crossing the SDK boundary; those go through the shim so every request, response and transport object comes from the library the installed SDK actually uses.The boundary matters in both directions. Rebasing onto the PAT-exchange merge (#27) surfaced this:
token_exchange.pybuilds its own synchronoushttpx.Clientfor the RFC 8693 call and never hands it to the SDK, so it correctly keeps importinghttpxdirectly. Blanket-swapping the test file's import broke seven exchange tests — aMockTransportfrom the other library is silently not used, and the exchange then tried to reach the real network:test_http_errors_e2e.pynow imports both deliberately:httpx(shim) for SDK-boundary objects,exchange_httpx(plain) for the exchange transport.CLAUDE.mddocuments which side a new import belongs on.tests/support.pybuilds messages by validating a raw wire payload through the SDK's own schema (TypeAdapter(JSONRPCMessage)), which accepts both the root-model and union shapes and keeps the JSON that actually goes on the wire as the source of truth.CI gains a
test-sdk-majorsjob. The lockfile only ever proves whichever major it happens to hold, so each supported major is resolved and exercised explicitly (unit tests + mypy). Without it this regresses the moment the lock moves.Verification
Run against both
mcp==1.29.0andmcp==2.0.0:make test— 411 passedmake check— ruff lint, ruff format, mypy strict, all cleanPlus a live end-to-end run outside the suite: the real
uc-mcp-proxysubprocess against a real MCP Streamable HTTP server over a real localhost socket, with realDatabricksAuthheader injection —initialize,tools/list, and atools/callround-trip all verified on both SDK majors. Nothing mocked.The existing
test_http_errors_e2e.pysuite is what caught theSessionMessagechange: 28 tests went red on 2.0 before the fix, which is why this is not just a one-line unpack patch.Rebased onto
1f57152(post-#27).Notes for review
0f2fa1e(test:) is a drive-by and unrelated to SDK 2.0 — it carriesSystemRoot/SystemDriveinto the subprocess test's env. That env is built from scratch so a developer's realDATABRICKS_*cannot reach the child, but on Windows that also dropsSystemRoot, and winsock then fails to initialize: the child dies importing asyncio's proactor loop withWinError 10106before reaching the code under test. No effect on Linux CI. Happy to drop it into its own PR if you'd rather keep this one focused.httpxis left independencies. It is still correct for SDK 1.x, and on 2.xhttpx2arrives transitively throughmcp. A static dep list cannot express "httpx if mcp<2 else httpx2".httpxstubs, which describe httpx2 accurately for every name used here. The one place the two genuinely cannot be reconciled statically — passing the client into the SDK — is an explicitcastwith a comment.test_proxy_process_exits_on_401_with_stdin_still_openis timing-sensitive and fails intermittently on slow filesystems, taking the_reportfallback path or exceeding its 30s budget. I reproduced it on unmodifiedmasterwithmcp==1.29.0, so it predates this change and I left it alone.